# typed: false
# frozen_string_literal: true

require "spec_helper"
require "support/dependency_file_helpers"

require "dependabot/bundler"

require "dependabot/service"
require "dependabot/update_graph_processor"

RSpec.describe Dependabot::UpdateGraphProcessor do
  subject(:update_graph_processor) do
    described_class.new(
      service:,
      job:,
      base_commit_sha:,
      dependency_files:
    )
  end

  let(:service) do
    instance_double(
      Dependabot::Service,
      create_dependency_submission: nil,
      record_update_job_error: nil
    )
  end

  let(:credentials) do
    [Dependabot::Credential.new(
      {
        "type" => "git_source",
        "host" => "github.com",
        "username" => "x-access-token",
        "password" => "token"
      }
    )]
  end

  let(:repo) { "dependabot-fixtures/dependabot-test-ruby-package" }
  let(:branch) { "develop" }
  let(:provider) { "github" }

  let(:source) do
    Dependabot::Source.new(
      provider: provider,
      repo: repo,
      directories: directories,
      branch: branch
    )
  end

  let(:job) do
    instance_double(
      Dependabot::Job,
      id: "42",
      package_manager: "bundler",
      repo_contents_path: repo_contents_path,
      credentials: credentials,
      source: source,
      reject_external_code?: false,
      experiments: { large_hadron_collider: true }
    )
  end

  let(:base_commit_sha) { "fake-sha" }
  let(:repo_contents_path) { nil }

  context "with a basic Gemfile project" do
    let(:directories) { [directory] }
    let(:directory) { "/" }
    let(:repo_contents_path) { build_tmp_repo("bundler/original", path: "") }

    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler/original/Gemfile"),
          directory: directory
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: fixture("bundler/original/Gemfile.lock"),
          directory: directory
        )
      ]
    end

    it "emits the expected payload to the Dependabot service" do
      expect(service).to receive(:create_dependency_submission) do |args|
        expect(args[:dependency_submission]).to be_a(GithubApi::DependencySubmission)

        payload = args[:dependency_submission].payload

        # Job references are as expected
        expect(payload[:job][:correlator]).to eq("dependabot-bundler")
        expect(payload[:job][:id]).to eq("42")

        # Git references are as expected
        expect(payload[:sha]).to eq(base_commit_sha)
        expect(payload[:ref]).to eql("refs/heads/#{branch}")

        # Manifest information is as expected
        expect(payload[:manifests].length).to eq(1)

        # Lockfile data is correct
        lockfile = payload[:manifests].fetch("/Gemfile.lock")
        expect(lockfile[:name]).to eq("/Gemfile.lock")
        expect(lockfile[:file][:source_location]).to eq("Gemfile.lock")

        # Resolved dependencies are correct
        expect(lockfile[:resolved].length).to eq(2)

        dependency1 = lockfile[:resolved]["pkg:gem/dummy-pkg-a@2.0.0"]
        expect(dependency1[:package_url]).to eql("pkg:gem/dummy-pkg-a@2.0.0")

        dependency2 = lockfile[:resolved]["pkg:gem/dummy-pkg-b@1.1.0"]
        expect(dependency2[:package_url]).to eql("pkg:gem/dummy-pkg-b@1.1.0")
      end

      update_graph_processor.run
    end
  end

  context "with a small sinatra app" do
    let(:directories) { [directory] }
    let(:directory) { "/" }
    let(:repo_contents_path) { build_tmp_repo("bundler_sinatra_app/original", path: "") }

    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler_sinatra_app/original/Gemfile"),
          directory: directory
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: fixture("bundler_sinatra_app/original/Gemfile.lock"),
          directory: directory
        )
      ]
    end

    it "emits the expected payload to the Dependabot service" do
      expect(service).to receive(:create_dependency_submission) do |args|
        payload = args[:dependency_submission].payload

        # Manifest information is as expected
        expect(payload[:manifests].length).to eq(1)
        lockfile = payload[:manifests].fetch("/Gemfile.lock")

        # Resolved dependencies are correct:
        expect(lockfile[:resolved].length).to eq(28)

        # the lockfile should be reporting 4 direct dependencies and 24 indirect ones
        expect(lockfile[:resolved].values.count { |dep| dep[:relationship] == "direct" }).to eq(4)
        expect(lockfile[:resolved].values.count { |dep| dep[:relationship] == "indirect" }).to eq(24)

        # the following top-level packages should be defined in the right groups
        {
          "sinatra" => "4.1.1",
          "pry" => "0.15.2",
          "rspec" => "3.13.1",
          "capybara" => "3.40.0"
        }.each do |pkg_name, version|
          key = "pkg:gem/#{pkg_name}@#{version}"
          resolved_dep = lockfile[:resolved][key]

          expect(resolved_dep).not_to be_empty
          expect(resolved_dep[:relationship]).to eq("direct")

          case pkg_name
          when "sinatra"
            expect(resolved_dep[:package_url]).to eql("pkg:gem/sinatra@4.1.1")
            expect(resolved_dep[:scope]).to eq("runtime")
          when "pry"
            expect(resolved_dep[:package_url]).to eql("pkg:gem/pry@0.15.2")
            expect(resolved_dep[:scope]).to eq("development")
          when "rspec"
            expect(resolved_dep[:package_url]).to eql("pkg:gem/rspec@3.13.1")
            expect(resolved_dep[:scope]).to eq("development")
          when "capybara"
            expect(resolved_dep[:package_url]).to eql("pkg:gem/capybara@3.40.0")
            expect(resolved_dep[:scope]).to eq("development")
          end
        end

        # the direct ones were verified above.
        # let's pull out a few indirect dependencies to check
        rack = lockfile[:resolved]["pkg:gem/rack@3.1.16"]
        expect(rack[:package_url]).to eql("pkg:gem/rack@3.1.16")
        expect(rack[:relationship]).to eq("indirect")
        expect(rack[:scope]).to eq("runtime")

        addressable = lockfile[:resolved]["pkg:gem/addressable@2.8.7"]
        expect(addressable[:package_url]).to eql("pkg:gem/addressable@2.8.7")
        expect(addressable[:relationship]).to eq("indirect")
        expect(addressable[:scope]).to eq("development")
      end

      update_graph_processor.run
    end
  end

  context "with a job that specifies multiple directories" do
    let(:directories) { [dir1, dir2] }

    let(:dir1) { "/" }
    let(:dir2) { "/subproject/" }
    let(:repo_contents_path) { build_tmp_repo("bundler_sinatra_app/original", path: "") }

    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler_sinatra_app/original/Gemfile"),
          directory: dir1
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: fixture("bundler_sinatra_app/original/Gemfile.lock"),
          directory: dir1
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler/original/Gemfile"),
          directory: dir2
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: fixture("bundler/original/Gemfile.lock"),
          directory: dir2
        )
      ]
    end

    it "emits a snapshot for each directory" do
      expect(service).to receive(:create_dependency_submission).twice

      update_graph_processor.run
    end

    it "correctly snapshots the first directory" do
      payload = nil

      # Capture the first call
      expect(service).to receive(:create_dependency_submission) do |args|
        payload = args[:dependency_submission].payload
      end
      expect(service).to receive(:create_dependency_submission).once

      update_graph_processor.run

      expect(payload).not_to be_nil
      expect(payload[:job][:correlator]).to eql("dependabot-bundler")

      # Check we have a Sinatra app with 28 dependencies
      expect(payload[:manifests].length).to eq(1)
      lockfile = payload[:manifests].fetch("/Gemfile.lock")

      expect(lockfile[:resolved].length).to eq(28)

      expect(lockfile[:resolved].values.count { |dep| dep[:relationship] == "direct" }).to eq(4)
      expect(lockfile[:resolved].values.count { |dep| dep[:relationship] == "indirect" }).to eq(24)
    end

    it "correctly snapshots the second directory" do
      payload = nil

      expect(service).to receive(:create_dependency_submission).once
      # Capture the second call
      expect(service).to receive(:create_dependency_submission) do |args|
        payload = args[:dependency_submission].payload
      end

      update_graph_processor.run

      expect(payload).not_to be_nil
      expect(payload[:job][:correlator]).to eql("dependabot-bundler-subproject")

      # Check we have the simple app with 2 dependencies
      expect(payload[:manifests].length).to eq(1)
      lockfile = payload[:manifests].fetch("/subproject/Gemfile.lock")

      expect(lockfile[:resolved].length).to eq(2)

      dependency1 = lockfile[:resolved]["pkg:gem/dummy-pkg-a@2.0.0"]
      expect(dependency1[:package_url]).to eql("pkg:gem/dummy-pkg-a@2.0.0")
      dependency2 = lockfile[:resolved]["pkg:gem/dummy-pkg-b@1.1.0"]
      expect(dependency2[:package_url]).to eql("pkg:gem/dummy-pkg-b@1.1.0")
    end

    context "when the first directory fails to process" do
      let(:dependency_files) do
        [
          Dependabot::DependencyFile.new(
            name: "Gemfile",
            content: "garbage",
            directory: dir1
          ),
          Dependabot::DependencyFile.new(
            name: "Gemfile.lock",
            content: "garbage in greater volume",
            directory: dir1
          ),
          Dependabot::DependencyFile.new(
            name: "Gemfile",
            content: fixture("bundler/original/Gemfile"),
            directory: dir2
          ),
          Dependabot::DependencyFile.new(
            name: "Gemfile.lock",
            content: fixture("bundler/original/Gemfile.lock"),
            directory: dir2
          )
        ]
      end

      context "when executing standalone" do
        before do
          allow(Dependabot::Environment).to receive(:github_actions?).and_return(false)
        end

        it "emits a snapshot and an error" do
          expect(service).to receive(:create_dependency_submission).once
          expect(service).to receive(:record_update_job_error).once

          update_graph_processor.run
        end

        it "correctly snapshots the second directory" do
          payload = nil

          expect(service).to receive(:create_dependency_submission) do |args|
            payload = args[:dependency_submission].payload
          end

          update_graph_processor.run

          expect(payload).not_to be_nil
          expect(payload[:job][:correlator]).to eql("dependabot-bundler-subproject")

          # Check we have the simple app with 2 dependencies
          expect(payload[:manifests].length).to eq(1)
          lockfile = payload[:manifests].fetch("/subproject/Gemfile.lock")

          expect(lockfile[:resolved].length).to eq(2)

          dependency1 = lockfile[:resolved]["pkg:gem/dummy-pkg-a@2.0.0"]
          expect(dependency1[:package_url]).to eql("pkg:gem/dummy-pkg-a@2.0.0")
          dependency2 = lockfile[:resolved]["pkg:gem/dummy-pkg-b@1.1.0"]
          expect(dependency2[:package_url]).to eql("pkg:gem/dummy-pkg-b@1.1.0")
        end
      end

      context "when executing in GitHub Actions" do
        before do
          allow(Dependabot::Environment).to receive(:github_actions?).and_return(true)
        end

        it "emits a blank snapshot, a normal snapshot and an error" do
          expect(service).to receive(:create_dependency_submission).twice
          expect(service).to receive(:record_update_job_error).once

          update_graph_processor.run
        end

        it "emits a blank snapshot for the first directory" do
          payload = nil

          # Capture the first call
          expect(service).to receive(:create_dependency_submission) do |args|
            payload = args[:dependency_submission].payload
          end
          expect(service).to receive(:create_dependency_submission).once

          update_graph_processor.run

          expect(payload).not_to be_nil
          expect(payload[:job][:correlator]).to eql("dependabot-bundler")

          # It should be empty
          expect(payload[:manifests].length).to be_zero
        end

        it "correctly snapshots the second directory" do
          payload = nil

          # Capture the second call
          expect(service).to receive(:create_dependency_submission).once
          expect(service).to receive(:create_dependency_submission) do |args|
            payload = args[:dependency_submission].payload
          end

          update_graph_processor.run

          expect(payload).not_to be_nil
          expect(payload[:job][:correlator]).to eql("dependabot-bundler-subproject")

          # Check we have the simple app with 2 dependencies
          expect(payload[:manifests].length).to eq(1)
          lockfile = payload[:manifests].fetch("/subproject/Gemfile.lock")

          expect(lockfile[:resolved].length).to eq(2)

          dependency1 = lockfile[:resolved]["pkg:gem/dummy-pkg-a@2.0.0"]
          expect(dependency1[:package_url]).to eql("pkg:gem/dummy-pkg-a@2.0.0")
          dependency2 = lockfile[:resolved]["pkg:gem/dummy-pkg-b@1.1.0"]
          expect(dependency2[:package_url]).to eql("pkg:gem/dummy-pkg-b@1.1.0")
        end
      end
    end
  end

  context "with vendored files" do
    let(:directories) { [directory] }
    let(:directory) { "/" }
    let(:repo_contents_path) { build_tmp_repo("bundler_vendored/original", path: "") }

    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler/original/Gemfile"),
          directory: directory
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: fixture("bundler/original/Gemfile.lock"),
          directory: directory
        ),
        Dependabot::DependencyFile.new(
          name: "vendor/ruby/3.4.0/cache/addressable-2.8.7.gem",
          content: "stuff",
          directory: directory,
          support_file: true,
          vendored_file: true
        )
      ]
    end

    it "they are not mentioned in the dependency submission payload" do
      expect(service).to receive(:create_dependency_submission) do |args|
        payload = args[:dependency_submission].payload

        # We only expect a lockfile to be returned
        expect(payload[:manifests].length).to eq(1)
        expect(payload[:manifests].keys).to eq(%w(/Gemfile.lock))
      end

      update_graph_processor.run
    end
  end

  context "without a Gemfile.lock" do
    let(:directories) { [directory] }
    let(:directory) { "/" }
    let(:repo_contents_path) { build_tmp_repo("bundler/original", path: "") }

    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler/original/Gemfile"),
          directory: directory
        )
      ]
    end

    it "submits only the Gemfile" do
      expect(service).to receive(:create_dependency_submission) do |args|
        payload = args[:dependency_submission].payload

        # We only expect a Gemfile to be returned
        expect(payload[:manifests].length).to eq(1)

        # Gemfile data is correct
        gemfile = payload[:manifests].fetch("/Gemfile")
        expect(gemfile[:name]).to eq("/Gemfile")
        expect(gemfile[:file][:source_location]).to eq("Gemfile")

        # Resolved dependencies are correct
        expect(gemfile[:resolved].length).to eq(2)

        dependency1 = gemfile[:resolved]["pkg:gem/dummy-pkg-a"]
        expect(dependency1[:package_url]).to eql("pkg:gem/dummy-pkg-a")

        dependency2 = gemfile[:resolved]["pkg:gem/dummy-pkg-b"]
        expect(dependency2[:package_url]).to eql("pkg:gem/dummy-pkg-b")
      end

      update_graph_processor.run
    end
  end

  # This is mainly for documentation purposes, this is unlikely to happen in the real world.
  context "with a set of empty dependency files" do
    let(:directories) { [directory] }
    let(:directory) { "/" }
    let(:repo_contents_path) { build_tmp_repo("bundler/original", path: "") }

    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: "",
          directory: directory
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: "",
          directory: directory
        )
      ]
    end

    it "generates a snapshot with metadata and an empty manifest list" do
      expect(service).to receive(:create_dependency_submission) do |args|
        payload = args[:dependency_submission].payload

        expect(payload[:job][:correlator]).to eq("dependabot-bundler")
        expect(payload[:manifests]).to be_empty
      end

      update_graph_processor.run
    end
  end

  describe "job validation" do
    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler/original/Gemfile"),
          directory: "/"
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: fixture("bundler/original/Gemfile.lock"),
          directory: "/"
        )
      ]
    end

    context "when the source has no directories defined" do
      let(:directories) { nil }

      it "raises an error" do
        expect { update_graph_processor.run }.to raise_error(Dependabot::DependabotError)
      end
    end

    context "when the source directories are empty" do
      let(:directories) { [] }

      it "raises an error" do
        expect { update_graph_processor.run }.to raise_error(Dependabot::DependabotError)
      end
    end

    context "when the source does not specify a branch" do
      let(:directories) { ["/"] }
      let(:branch) { nil }
      let(:repo_contents_path) { build_tmp_repo("bundler/original", path: "") }

      it "retrieves the default branch via Git" do
        allow(Dependabot::SharedHelpers).to receive(:run_shell_command).and_return("origin/very-esoteric-naming\n")

        expect(service).to receive(:create_dependency_submission) do |args|
          payload = args[:dependency_submission].payload

          expect(payload[:ref]).to eql("refs/heads/very-esoteric-naming")
        end

        update_graph_processor.run
      end
    end
  end

  context "when fetching subdependencies fails" do
    let(:directories) { [directory] }
    let(:directory) { "/" }
    let(:repo_contents_path) { build_tmp_repo("bundler/original", path: "") }

    let(:dependency_files) do
      [
        Dependabot::DependencyFile.new(
          name: "Gemfile",
          content: fixture("bundler/original/Gemfile"),
          directory: directory
        ),
        Dependabot::DependencyFile.new(
          name: "Gemfile.lock",
          content: fixture("bundler/original/Gemfile.lock"),
          directory: directory
        )
      ]
    end

    before do
      original_grapher_class = Dependabot::DependencyGraphers.for_package_manager(job.package_manager)

      failing_grapher_class = Class.new(original_grapher_class) do
        def initialize(file_parser:)
          super
          @raise_once = true
        end

        def fetch_subdependencies(_dependency)
          if @raise_once
            @raise_once = false
            raise StandardError, "boom"
          end
          []
        end
      end

      allow(Dependabot::DependencyGraphers).to receive(:for_package_manager)
        .with(job.package_manager).and_return(failing_grapher_class)
    end

    it "records a dependency_file_not_resolvable error and still submits a dependency submission" do
      expect(service).to receive(:create_dependency_submission) do |args|
        payload = args[:dependency_submission].payload
        expect(payload[:manifests]).to be_a(Hash)
      end

      expect(service).to receive(:record_update_job_error) do |args|
        expect(args[:error_type]).to eq("dependency_file_not_resolvable")
        expect(args[:error_details]).to include(:message)
      end

      update_graph_processor.run
    end
  end
end
